Skip to content

docs(api): document exact /status schema separating process vs data health (#273) - #533

Merged
Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
sparkysolutions10-pixel:feat/status-schema-docs-273
Oct 23, 2026
Merged

Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
sparkysolutions10-pixel:feat/status-schema-docs-273

Conversation

@sparkysolutions10-pixel

Copy link
Copy Markdown
Contributor

Closes #273

Summary of Changes

  • Documents the exact JSON response shape for /status in openapi.yaml, openapi.json, and README.md.
  • Explicitly separates process health (uptimeSeconds, memoryUsageMb, status) from data health (lastIndexedLedger, lastProcessedAt, ingestLagSeconds, isStale).
  • Formulates deterministic status degradation (status: "degraded" when ingestLagSeconds > 300 but server process is healthy) without false alarm paging.
  • Returns 503 Service Unavailable with pagingCondition: "DATABASE_DOWN" only when critical infrastructure failures occur (e.g. Postgres disconnect), providing a clear paging threshold for uptime monitors.
  • Adds concrete Datadog / Prometheus / BetterStack monitoring configuration examples to README.md.
  • Added unit tests in src/__tests__/statusContract.test.ts.

Miracle656 added a commit that referenced this pull request Oct 23, 2026
…loses #273)

`/status` now reports `status`, `health.process`, `health.data`, a `process`
block (uptime, heap) and a `data` block (ledger, lag, `isStale`), keeps the old
top-level fields so existing consumers are unaffected, returns a 200 with
`status: "degraded"` when ingest lag passes 300s, and a 503 with
`pagingCondition: "DATABASE_DOWN"` when Postgres fails — which is the paging
threshold the issue asked for. openapi.yaml/json, the TS client and the README
monitoring examples are updated together.

Two fixes on top of the contribution.

1. **The 503 body returned the Postgres driver's own message on a public,
   unauthenticated route.** `error: err?.message || 'Database query failed'`,
   on the most-polled endpoint Lens has. A pg connection failure puts the host,
   the user and sometimes the entire connection string into that message —
   Lens has already had to rotate a database password over exactly this leak
   on `/price/twap`, and the PR's own test pinned it
   (`expect(body.error).toContain('Connection terminated')`). The response is
   now a fixed `"Database unavailable"`, the detail goes to `req.log.error`
   where buildLogger pipes it through `redactSecrets`, and
   `statusErrorResponseSchema` gained `additionalProperties: false` so a
   `detail` field cannot be added back later. The test is inverted, and a
   second one asserts a connection-string-bearing error reaches neither the
   body nor the serialized response.

2. **Process health was hard-coded.** `const processHealth = 'healthy' as const`
   meant `health.process` and `process.status` could never be anything else,
   and the `degraded` branch of the published enum was unreachable — so the
   split this endpoint exists to make was cosmetic on one side. It now reads
   `isBootComplete()`: between `listen()` and `markBootComplete()` the port
   answers while not every ingester is running, which is a degraded process
   with perfectly fresh data. Covered by a test, and documented in the README
   next to the paging thresholds, pointing at `/ready` as the rollout gate.

Also named the 300-second staleness threshold, since the README's
Datadog/Prometheus/BetterStack examples and the openapi `degraded` contract all
quote it and a monitor tuned to a different number is worse than none.

125 test files / 1,486 tests pass; tsc and build clean.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
@Miracle656
Miracle656 merged commit b470a46 into Divinity-Labs:main Oct 23, 2026
1 of 2 checks passed
@Miracle656

Copy link
Copy Markdown
Collaborator

Merged as 794eb07. The framing is the valuable part here: "do not page on upstream ledger delay, do page on Postgres being gone" is a real operational distinction, and encoding it as a 200-with-degraded versus a 503-with-pagingCondition is the right way to make it machine-readable. Keeping the old top-level lastIndexedLedger / lastProcessedAt / ingestLagSeconds alongside the new data block means no existing consumer breaks, which is easy to forget on a monitored endpoint. The Datadog/Prometheus/BetterStack examples in the README are the kind of thing that actually gets copied.

Two fixes before it went in.


1. The 503 returned the Postgres driver's message on a public endpoint (blocking)

} catch (err: any) {
  return reply.status(503).send({
    ok: false,
    status: 'unhealthy',
    error: err?.message || 'Database query failed',
    pagingCondition: 'DATABASE_DOWN',
  })
}

/status is config: { public: true } — no API key, no admin token — and it is the most-polled surface Lens has (UptimeRobot hits it continuously). A pg connection failure does not produce a tidy message; it produces things like:

connect ECONNREFUSED: password authentication failed for user "lens_admin"
(postgresql://lens_admin:hunter2@db.internal.example:5432/lens)

Lens has already had to rotate a database password over this exact shape of leak on /price/twap, where a raw pg error was echoed to the client and sat there for roughly three months on a database shared with another service. So this is not a hypothetical — it is the same bug on a louder route.

What makes it worth flagging rather than just fixing quietly is that the test pinned the leak as the intended behaviour:

expect(body.error).toContain('Connection terminated')

A test asserting a leaked driver message is a test that will defend it against the next person who tries to remove it. I inverted it, and added a second case that feeds in a connection-string-bearing error and asserts none of hunter2, lens_admin, db.internal.example or postgresql:// appears anywhere in the serialized response.

The fix keeps the diagnostic, it just sends it to the right place:

} catch (err: unknown) {
  req.log.error({ err }, 'status: database query failed')
  return reply.status(503).send({
    ok: false,
    status: 'unhealthy',
    error: 'Database unavailable',
    pagingCondition: 'DATABASE_DOWN',
  })
}

buildLogger pipes log lines through redactSecrets (src/logger.ts), which knows about DATABASE_URL and the password inside it — so the log keeps the full driver text with the credentials scrubbed, which is exactly what an operator needs and the client does not. I also gave statusErrorResponseSchema additionalProperties: false, matching the 200 schema, so a future detail field cannot quietly reopen this.

Nothing is lost for the monitor: status_code == 503 || body.pagingCondition == 'DATABASE_DOWN' from your own README example still fires identically.

2. Process health was a constant

const processHealth = 'healthy' as const

health.process and process.status could never be anything but 'healthy', which means the degraded branch of the enum you published —

process:
  type: string
  enum: [healthy, degraded]

— was unreachable. So "separates process health from data health" was true on the data side and decorative on the process side: uptimeSeconds and memoryUsageMb are genuinely measured, but the health verdict derived from them was not.

It now reads a real signal:

const processHealth = isBootComplete() ? ('healthy' as const) : ('degraded' as const)

isBootComplete() (src/health.ts) is the same thing /ready gates on, and it is the one process-level state that is genuinely distinct from data freshness: between app.listen() and markBootComplete() the port answers requests while not every ingester is running yet. That is a degraded process with perfectly fresh data — the exact quadrant your two-axis model exists to express, and now the test for it asserts health.process: 'degraded' with health.data: 'healthy'.

One knock-on: statusContract.test.ts now calls markBootComplete() in beforeEach. A test that means "a healthy process" has to say so; without it buildApp() leaves the app mid-boot, which is the degraded case.

I also pulled the 300-second threshold out into a named constant. It is a published number — the README monitoring examples quote > 300 and the openapi degraded description depends on it — and a monitor tuned to one value while the service uses another is worse than no monitor at all.


Generated artifacts

openapi.json and clients/typescript/src/schema.d.ts are both guarded in CI, so both are regenerated in the merge commit (npm run openapi:gen, then openapi-typescript against openapi.yaml). Worth knowing for next time: npm run client:gen needs openapi-typescript on PATH, so locally it is npx --yes openapi-typescript@7.13.0 openapi.yaml -o clients/typescript/src/schema.d.ts.

125 test files / 1,486 tests pass. tsc --noEmit clean.

Azintar added a commit to Azintar/Lens that referenced this pull request Oct 24, 2026
These duplicate work already on main through Divinity-Labs#533 (Divinity-Labs#273). This PR is
for Divinity-Labs#275 only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/status has no documented shape

2 participants